S3 Presigned URL로 업로드 부하 분산하기

S3 Presigned URL로 업로드 부하 분산하기

한눈에 보기

Presigned URL을 사용하면 애플리케이션 서버는 업로드 권한과 객체 키만 발급하고, 파일 바이트는 브라우저가 S3로 직접 전송한다. 서버 부하는 줄지만 URL은 만료 전까지 해당 작업을 수행할 수 있는 bearer credential에 가깝다. 짧은 만료 시간, 예측 불가능한 키, 최소 IAM 권한, 크기·형식 제한, 업로드 완료 검증을 함께 설계해야 한다.

목차

애플리케이션 서버를 거치는 업로드의 비용

가장 단순한 업로드 API는 브라우저가 파일을 애플리케이션 서버로 보내고 서버가 다시 S3에 저장하는 구조다.

flowchart LR
    A[Browser] -->|500MB 업로드| B[Application API]
    B -->|다시 500MB 전송| C[(Amazon S3)]

이 구조가 항상 잘못된 것은 아니다. 서버가 업로드 스트림을 즉시 검사하거나 변환해야 한다면 필요할 수 있다. 다만 일반적인 원본 파일 보관에서는 다음 비용이 생긴다.

100명의 사용자가 각각 200MB를 동시에 올리면 API가 중계해야 할 입력만 20GB다. S3로 내보내는 트래픽도 별도로 발생한다. 비즈니스 로직이 파일 바이트를 읽을 필요가 없다면 API를 데이터 경로에서 제외할 수 있다.

Presigned URL은 AWS 자격 증명을 클라이언트에 전달하지 않고, 특정 S3 작업을 제한된 시간 동안 수행할 수 있게 서명한 요청이다.

역할 변화

API 서버가 파일을 운반하는 서버에서 업로드의 권한과 상태를 관리하는 서버로 바뀐다.

Presigned URL 업로드 흐름

전체 흐름은 보통 세 단계다.

sequenceDiagram
    participant U as Browser
    participant API
    participant DB
    participant S3
    U->>API: 1. 업로드 세션 요청(name, size, type)
    API->>DB: 권한 확인 + PENDING 세션 저장
    API-->>U: objectKey + presigned URL
    U->>S3: 2. 파일 PUT
    S3-->>U: ETag / 성공
    U->>API: 3. 업로드 완료 요청
    API->>S3: HeadObject로 확인
    API->>DB: READY 상태로 전이
    API-->>U: attachmentId

각 단계의 책임은 다르다.

  1. 업로드 준비: 인증된 사용자인지, 이 기능을 사용할 권한이 있는지, 허용된 크기와 형식인지 검사한다. 서버가 객체 키를 만들고 PENDING 레코드를 저장한다.
  2. 바이트 전송: 브라우저가 URL을 사용해 S3에 직접 PUT한다. API 서버는 바이트를 보지 않는다.
  3. 완료 확정: 클라이언트의 성공 주장만 믿지 않고 S3 객체의 크기와 메타데이터를 확인한다. 이후 비동기 검사 대상으로 보낸다.

URL 발급과 DB 레코드 생성 순서도 중요하다. DB에 업로드 의도를 남기지 않고 URL만 발급하면 어떤 사용자가 어떤 객체를 만들었는지 추적하기 어렵다.

URL을 발급하기 전에 결정할 것

업로드 준비 요청에는 파일 자체가 아니라 서버가 정책을 판단하는 데 필요한 메타데이터만 보낸다.

{
  "fileName": "profile-photo.png",
  "contentType": "image/png",
  "size": 1843200,
  "purpose": "PROFILE_IMAGE"
}

DTO는 기본 모양을 검사한다.

export class PrepareUploadDto {
  @IsString()
  @Length(1, 180)
  fileName!: string;

  @IsIn(["image/png", "image/jpeg", "application/pdf"])
  contentType!: string;

  @IsInt()
  @Min(1)
  @Max(10 * 1024 * 1024)
  size!: number;

  @IsIn(["PROFILE_IMAGE", "DOCUMENT"])
  purpose!: "PROFILE_IMAGE" | "DOCUMENT";
}

그러나 클라이언트가 보낸 contentTypesize는 아직 주장일 뿐이다. 실제 객체를 올린 후 다시 확인해야 한다.

객체 키는 서버가 만든다

원본 파일명을 그대로 S3 key로 사용하면 문제가 생긴다.

서버가 소유 범위와 무작위 ID를 조합한다.

function createObjectKey(input: {
  userId: string;
  extension: "png" | "jpg" | "pdf";
}): string {
  const uploadId = crypto.randomUUID();

  return [
    "private",
    "uploads",
    input.userId,
    `${uploadId}.${input.extension}`,
  ].join("/");
}

원본 파일명은 DB 메타데이터로 따로 저장하고 다운로드 응답의 Content-Disposition에 사용할 때 안전하게 인코딩한다.

확장자는 보안 검증이 아니다

.png라는 이름과 image/png 헤더만으로 실제 PNG 파일임을 보장할 수 없다. Magic bytes 검사와 디코딩, 악성코드 검사는 업로드 후 격리 구역에서 수행한다.

서버에서 PUT Presigned URL 발급하기

AWS SDK for JavaScript v3를 사용하는 가상의 NestJS 서비스를 구성해 보자.

import {
  PutObjectCommand,
  S3Client,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

type PrepareUploadCommand = {
  userId: string;
  originalName: string;
  contentType: string;
  size: number;
  purpose: "PROFILE_IMAGE" | "DOCUMENT";
};

class UploadPreparationService {
  constructor(
    private readonly s3: S3Client,
    private readonly uploads: UploadRepository,
    private readonly bucketName: string,
  ) {}

  async prepare(command: PrepareUploadCommand): Promise<PreparedUpload> {
    const policy = resolveUploadPolicy(command.purpose);
    policy.assertAllowed(command.contentType, command.size);

    const uploadId = crypto.randomUUID();
    const extension = extensionFor(command.contentType);
    const objectKey =
      `quarantine/${command.userId}/${uploadId}.${extension}`;

    await this.uploads.createPending({
      id: uploadId,
      userId: command.userId,
      objectKey,
      originalName: command.originalName,
      expectedContentType: command.contentType,
      expectedSize: command.size,
      purpose: command.purpose,
      expiresAt: addMinutes(new Date(), 10),
    });

    const putCommand = new PutObjectCommand({
      Bucket: this.bucketName,
      Key: objectKey,
      ContentType: command.contentType,
      Metadata: {
        "upload-id": uploadId,
      },
    });

    const url = await getSignedUrl(this.s3, putCommand, {
      expiresIn: 5 * 60,
    });

    return {
      uploadId,
      objectKey,
      method: "PUT",
      url,
      headers: {
        "Content-Type": command.contentType,
      },
      expiresInSeconds: 300,
    };
  }
}

코드는 실제 프로젝트에서 복사한 것이 아니라 흐름을 설명하기 위한 재구성 예제다. 핵심은 서명한 명령과 클라이언트 요청이 일치해야 한다는 점이다. ContentType을 서명에 포함했다면 브라우저도 동일한 Content-Type을 보내야 한다.

API 응답은 다음처럼 URL과 필요한 헤더를 명시적으로 함께 준다.

{
  "uploadId": "5c65c7c8-41bd-4bb6-b197-36c8d8e9ecaf",
  "method": "PUT",
  "url": "https://example-bucket.s3.ap-northeast-2.amazonaws.com/...",
  "headers": {
    "Content-Type": "image/png"
  },
  "expiresInSeconds": 300
}

클라이언트가 SDK 세부사항을 추측하지 않게 method, headers, 만료 시간을 계약에 넣는다.

URL 발급 권한과 업로드 권한은 다르다

URL을 만드는 계산 자체가 S3에 네트워크 요청을 보내는 것은 아닐 수 있다. 그러나 서명에 사용한 IAM principal이 실제 PutObject 권한을 가져야 요청 시점에 S3가 허용한다. Presigned URL은 서명 principal이 갖지 않은 권한을 새로 만들어 내지 못한다.

서명 역할에는 가능한 한 특정 bucket과 prefix의 필요한 action만 허용한다.

{
  "Effect": "Allow",
  "Action": ["s3:PutObject"],
  "Resource": "arn:aws:s3:::example-private-bucket/quarantine/*"
}

목록 조회, bucket 삭제, 다른 prefix 읽기 권한까지 함께 주지 않는다.

브라우저에서 S3로 직접 업로드하기

클라이언트는 API가 준 메서드와 헤더로 요청한다.

type PreparedUpload = {
  uploadId: string;
  url: string;
  method: "PUT";
  headers: Record<string, string>;
};

async function uploadFile(
  prepared: PreparedUpload,
  file: File,
): Promise<void> {
  const response = await fetch(prepared.url, {
    method: prepared.method,
    headers: prepared.headers,
    body: file,
  });

  if (!response.ok) {
    throw new UploadFailedError({
      status: response.status,
    });
  }
}

Presigned URL 전체에는 서명 파라미터가 포함되므로 로그나 오류 추적 도구에 그대로 남기지 않는다.

// 잘못된 예
logger.error({ uploadUrl: prepared.url, error });

// 더 나은 예
logger.error({
  event: "direct_upload.failed",
  uploadId: prepared.uploadId,
  status: error.status,
});

브라우저의 업로드 진행률이 필요하면 XMLHttpRequest 또는 진행 이벤트를 지원하는 클라이언트를 사용할 수 있다.

function uploadWithProgress(
  prepared: PreparedUpload,
  file: File,
  onProgress: (ratio: number) => void,
): Promise<void> {
  return new Promise((resolve, reject) => {
    const request = new XMLHttpRequest();
    request.open("PUT", prepared.url);

    for (const [name, value] of Object.entries(prepared.headers)) {
      request.setRequestHeader(name, value);
    }

    request.upload.onprogress = (event) => {
      if (event.lengthComputable) {
        onProgress(event.loaded / event.total);
      }
    };
    request.onload = () =>
      request.status >= 200 && request.status < 300
        ? resolve()
        : reject(new UploadFailedError({ status: request.status }));
    request.onerror = () => reject(new NetworkError());
    request.send(file);
  });
}

업로드 완료를 별도로 확정해야 하는 이유

URL을 발급했다는 사실은 파일이 업로드되었다는 뜻이 아니다. 사용자가 화면을 닫거나 네트워크가 끊기면 PENDING 레코드만 남는다. 반대로 브라우저가 성공했다고 보고해도 서버는 최종 객체를 검증해야 한다.

완료 API는 uploadId를 받아 소유권과 상태를 확인하고 S3 HeadObject 결과를 비교한다.

import { HeadObjectCommand } from "@aws-sdk/client-s3";

class CompleteUploadService {
  async complete(
    userId: string,
    uploadId: string,
  ): Promise<CompletedUpload> {
    const upload = await this.uploads.findById(uploadId);

    if (!upload || upload.userId !== userId) {
      throw new UploadNotFoundError();
    }

    if (upload.status === "READY") {
      return upload;
    }

    const object = await this.s3.send(
      new HeadObjectCommand({
        Bucket: this.bucketName,
        Key: upload.objectKey,
      }),
    );

    if (object.ContentLength !== upload.expectedSize) {
      throw new UploadSizeMismatchError();
    }

    if (object.ContentType !== upload.expectedContentType) {
      throw new UploadTypeMismatchError();
    }

    if (object.Metadata?.["upload-id"] !== upload.id) {
      throw new UploadMetadataMismatchError();
    }

    return this.uploads.markUploaded(upload.id, {
      etag: object.ETag ?? null,
      uploadedAt: new Date(),
    });
  }
}

ETag를 곧바로 파일의 MD5라고 가정하지 않는다. Multipart upload나 암호화 방식에 따라 의미가 달라질 수 있다. 무결성이 중요하면 지원되는 checksum 헤더와 S3 검증 방식을 명시적으로 사용한다.

완료 API도 중복 호출될 수 있으므로 멱등하게 만든다. 이미 READY 또는 SCANNING 상태라면 기존 결과를 반환한다.

stateDiagram-v2
    [*] --> PENDING: URL 발급
    PENDING --> UPLOADED: HeadObject 확인
    UPLOADED --> SCANNING: 검사 작업 시작
    SCANNING --> READY: 안전 판정
    SCANNING --> REJECTED: 형식/악성코드 실패
    PENDING --> EXPIRED: 제한 시간 경과

오래된 PENDING 객체와 레코드는 정리 작업이나 S3 Lifecycle rule로 제거한다.

PUT과 POST 중 무엇을 선택할까

Presigned PUT과 Presigned POST는 둘 다 브라우저 직접 업로드에 사용할 수 있지만 제약 표현 방식이 다르다.

항목 Presigned PUT Presigned POST
요청 형태 객체 본문을 PUT multipart/form-data POST
구현 단순성 단일 파일에 간단 form fields 구성 필요
크기 제한 앱 정책 + 사후 확인, 서명 조건 구성 policy의 content-length-range 조건 활용 가능
key·Content-Type 제한 서명된 요청과 헤더 일치 policy condition으로 표현
브라우저 폼 업로드 직접 구성 자연스러움

클라이언트가 단일 객체를 올리고 서버에서 완료 검증을 수행한다면 PUT이 단순하다. 업로드 시점에 크기 범위를 S3 정책으로 강제하고 싶다면 POST를 고려한다.

Presigned POST의 개념적인 예시는 다음과 같다.

const result = await createPresignedPost(s3, {
  Bucket: bucketName,
  Key: objectKey,
  Expires: 300,
  Fields: {
    "Content-Type": contentType,
    "x-amz-meta-upload-id": uploadId,
  },
  Conditions: [
    ["content-length-range", 1, maxBytes],
    ["eq", "$Content-Type", contentType],
    ["eq", "$x-amz-meta-upload-id", uploadId],
  ],
});

클라이언트는 반환된 fields를 모두 FormData에 넣고 마지막에 파일을 추가한다.

const form = new FormData();

for (const [name, value] of Object.entries(prepared.fields)) {
  form.append(name, value);
}

form.append("file", file);

await fetch(prepared.url, {
  method: "POST",
  body: form,
});

어느 방식이든 애플리케이션이 허용한 크기와 실제 업로드된 크기를 완료 단계에서 다시 비교한다.

보안 경계를 URL 하나로 착각하지 않기

Presigned URL은 임시 자격 증명처럼 취급해야 한다. URL을 가진 주체는 만료 전까지 서명된 작업을 수행할 수 있다.

짧은 만료 시간

사용자가 즉시 업로드하는 화면이라면 5~10분처럼 짧은 시간을 둔다. 무조건 긴 만료 시간을 주면 채팅, 브라우저 히스토리, 로그를 통해 유출되었을 때 악용 창이 커진다.

서명에 임시 자격 증명을 사용했다면 URL에 적힌 만료 시간보다 자격 증명이 먼저 끝날 수 있다. 반대로 URL을 발급했다고 해서 독립적인 revoke API가 생기는 것은 아니다.

단일 사용이라고 가정하지 않는다

공식 AWS guidance에 따르면 presigned request의 서명은 반복 사용될 수 있다. “한 번 업로드했으니 URL이 자동 폐기된다”는 보장이 없다. 같은 key에 다시 PUT하면 bucket versioning 설정에 따라 객체를 덮어쓰거나 새 버전을 만들 수 있다.

이를 줄이는 방법은 다음과 같다.

Bucket은 private으로 유지한다

업로드를 쉽게 하려고 public write나 public read를 열지 않는다. 다운로드도 인증이 필요하면 별도의 짧은 Presigned GET이나 CDN signed URL을 발급한다.

Bucket policy로 추가 guardrail을 둔다

조직 정책에 따라 s3:signatureAge, 네트워크 조건, 암호화 헤더 등을 검토할 수 있다. 단, 너무 강한 bucket policy는 AWS 서비스가 생성하는 presigned request나 모바일 네트워크 사용을 막을 수 있으므로 기존 사용을 먼저 관측한다.

URL과 원본 파일명을 로그에서 보호한다

Query string 전체, 원본 파일명, 사용자 식별자를 access log와 APM span에 남기는지 확인한다. 로그에는 uploadId, 익명화된 user ID, object key의 해시 정도만 필요한 경우가 많다.

Presigned URL이 해결하지 않는 것

사용자 인증, 업무 권한, 파일 내용 안전성, 정확히 한 번 업로드, 공개 범위는 Presigned URL이 대신 보장하지 않는다.

파일 검증과 후처리 파이프라인

API 서버가 파일 바이트를 보지 않으므로 업로드 완료 후 별도 검사 단계가 필요하다.

flowchart LR
    A[quarantine prefix] --> B[S3 Event]
    B --> C[Scanner Worker]
    C --> D{안전한 파일?}
    D -->|예| E[processed prefix]
    D -->|아니오| F[REJECTED + 삭제/격리]
    E --> G[썸네일/메타데이터 생성]
    G --> H[READY]

검사 항목은 용도에 따라 달라진다.

사용자 콘텐츠를 바로 공개 prefix에 저장하지 않고 quarantine/에 둔 뒤 안전 판정 후 복사하거나 상태로 접근을 통제한다.

S3 event delivery와 worker 실행은 중복될 수 있으므로 후처리도 멱등하게 만든다.

const claimed = await uploadRepository.claimForScanning({
  uploadId,
  expectedStatus: "UPLOADED",
});

if (!claimed) {
  return;
}

const result = await scanner.scan(objectKey);
await uploadRepository.completeScan(uploadId, result);

실패한 검사는 재시도 가능 오류와 영구 거절을 구분한다. scanner timeout은 재시도하지만 실제 형식 불일치는 REJECTED로 끝낸다.

CORS와 자주 만나는 실패

브라우저에서 S3로 다른 origin 요청을 보내므로 bucket CORS 설정이 필요하다.

[
  {
    "AllowedOrigins": ["https://app.example.test"],
    "AllowedMethods": ["PUT", "POST"],
    "AllowedHeaders": ["content-type", "x-amz-*"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 300
  }
]

운영에서는 * 대신 실제 origin과 필요한 method/header만 허용한다. CORS는 브라우저 정책이며 S3 IAM 권한을 부여하는 기능이 아니다.

자주 만나는 실패를 정리하면 다음과 같다.

증상 가능한 원인 확인할 것
브라우저 preflight 실패 CORS origin·header 누락 OPTIONS 응답과 bucket CORS
SignatureDoesNotMatch 서명한 헤더와 실제 헤더 불일치 Content-Type, host, method
AccessDenied IAM 또는 bucket policy 거부 signer principal과 key prefix
로컬은 되고 운영만 실패 region·endpoint·clock 설정 차이 S3 client region, 서버 시각
업로드는 성공했는데 완료 실패 size/type/metadata 불일치 HeadObject 결과
가끔 이전 파일이 보임 같은 key 재사용·캐시 UUID key, versioning, CDN cache

서명 오류를 디버깅하려고 전체 URL을 협업 채널에 붙이지 않는다. request ID, key, 서명된 헤더 목록, 만료 시각처럼 비밀이 아닌 정보로 조사한다.

대용량 파일은 Multipart Upload를 고려한다

수백 MB나 수 GB 파일은 단일 PUT 실패 시 처음부터 다시 보내야 한다. Multipart Upload는 파일을 여러 part로 나누고 실패한 part만 재전송할 수 있다.

흐름은 더 복잡하다.

  1. 서버가 multipart upload를 시작해 uploadId를 저장한다.
  2. 각 part 번호에 대한 presigned URL을 발급한다.
  3. 클라이언트가 part를 제한된 동시성으로 업로드하고 ETag를 모은다.
  4. 서버가 소유권과 part 목록을 검증한 뒤 complete 요청을 수행한다.
  5. 중단된 upload는 abort하고 수명 주기 정책으로 정리한다.
type UploadedPart = {
  partNumber: number;
  etag: string;
};

async function uploadParts(
  parts: Blob[],
  urls: string[],
): Promise<UploadedPart[]> {
  return mapWithConcurrency(parts, 4, async (part, index) => {
    const response = await fetch(urls[index], {
      method: "PUT",
      body: part,
    });

    if (!response.ok) {
      throw new PartUploadError(index + 1);
    }

    return {
      partNumber: index + 1,
      etag: response.headers.get("etag") ?? "",
    };
  });
}

동시성을 무제한으로 높이면 브라우저와 네트워크, S3 요청 수가 급증한다. 3~6개처럼 측정 가능한 제한부터 시작한다.

복잡성의 대가

프로필 이미지처럼 작은 파일에 Multipart Upload를 적용할 필요는 없다. 재개 가능성의 가치가 상태 관리와 정리 비용보다 큰 대용량 파일에 사용한다.

테스트와 운영 지표

Presigned URL 기능은 URL 문자열이 생성되는지만 테스트해서는 부족하다.

정책 단위 테스트

it("프로필 이미지는 5MB를 초과할 수 없다", () => {
  const policy = resolveUploadPolicy("PROFILE_IMAGE");

  expect(() =>
    policy.assertAllowed("image/png", 5 * 1024 * 1024 + 1),
  ).toThrow(FileTooLargeError);
});

통합 테스트

테스트 bucket이나 S3 호환 로컬 환경에서 실제 PUT과 HEAD 흐름을 검증한다.

it("서명된 헤더로 업로드한 객체만 완료할 수 있다", async () => {
  const prepared = await prepareUpload(validCommand());

  const response = await fetch(prepared.url, {
    method: "PUT",
    headers: prepared.headers,
    body: fixturePng,
  });

  expect(response.ok).toBe(true);

  const completed = await completeUpload(prepared.uploadId);
  expect(completed.status).toBe("UPLOADED");
});

다음 실패도 확인한다.

운영 지표

발급 수와 완료 수 차이가 갑자기 커지면 클라이언트 버그, CORS 오류, 서명 만료 시간 부족을 의심할 수 있다. 특정 사용자가 짧은 시간에 URL을 대량 발급하면 rate limit과 권한 악용 여부를 확인한다.

비용도 관측해야 한다. 저장 용량뿐 아니라 PUT·HEAD·LIST 요청, 실패한 multipart part, 검사 작업의 컴퓨팅 비용이 쌓인다. PENDINGREJECTED 객체에 Lifecycle rule을 두면 방치 비용을 줄일 수 있다.

정리

Presigned URL은 파일 업로드의 바이트 경로를 S3로 옮겨 API 서버의 bandwidth와 connection 점유를 크게 줄인다. 하지만 단순히 URL 한 줄을 발급하는 기능이 아니다.

애플리케이션 서버를 파일 전송 경로에서 빼는 대신, 업로드 세션의 소유권과 상태 전이, 사후 검증을 더 분명하게 관리하는 것이 Presigned URL 설계의 핵심이다.

관련 노트와 참고 자료